# frozen_string_literal: true

module Split
  class Experiment
    attr_accessor :name
    attr_accessor :goals
    attr_accessor :alternative_probabilities
    attr_accessor :metadata

    attr_reader :alternatives
    attr_reader :resettable

    DEFAULT_OPTIONS = {
      resettable: true
    }

    def self.find(name)
      Split.cache(:experiments, name) do
        return unless Split.redis.exists?(name)
        Experiment.new(name).tap { |exp| exp.load_from_redis }
      end
    end

    def initialize(name, options = {})
      options = DEFAULT_OPTIONS.merge(options)

      @name = name.to_s

      extract_alternatives_from_options(options)
    end

    def self.finished_key(key)
      "#{key}:finished"
    end

    def set_alternatives_and_options(options)
      options_with_defaults = DEFAULT_OPTIONS.merge(
        options.reject { |k, v| v.nil? }
      )

      self.alternatives = options_with_defaults[:alternatives]
      self.goals = options_with_defaults[:goals]
      self.resettable = options_with_defaults[:resettable]
      self.algorithm = options_with_defaults[:algorithm]
      self.metadata = options_with_defaults[:metadata]
    end

    def extract_alternatives_from_options(options)
      alts = options[:alternatives] || []

      if alts.length == 1
        if alts[0].is_a? Hash
          alts = alts[0].map { |k, v| { k => v } }
        end
      end

      if alts.empty?
        exp_config = Split.configuration.experiment_for(name)
        if exp_config
          alts = load_alternatives_from_configuration
          options[:goals] = Split::GoalsCollection.new(@name).load_from_configuration
          options[:metadata] = load_metadata_from_configuration
          options[:resettable] = exp_config[:resettable]
          options[:algorithm] = exp_config[:algorithm]
        end
      end

      options[:alternatives] = alts

      set_alternatives_and_options(options)

      # calculate probability that each alternative is the winner
      @alternative_probabilities = {}
      alts
    end

    def save
      validate!

      if new_record?
        start unless Split.configuration.start_manually
        persist_experiment_configuration
      elsif experiment_configuration_has_changed?
        reset unless Split.configuration.reset_manually
        persist_experiment_configuration
      end

      redis.hmset(experiment_config_key, :resettable, resettable.to_s,
                                         :algorithm, algorithm.to_s)
      self
    end

    def validate!
      if @alternatives.empty? && Split.configuration.experiment_for(@name).nil?
        raise ExperimentNotFound.new("Experiment #{@name} not found")
      end
      @alternatives.each { |a| a.validate! }
      goals_collection.validate!
    end

    def new_record?
      ExperimentCatalog.find(name).nil?
    end

    def ==(obj)
      self.name == obj.name
    end

    def [](name)
      alternatives.find { |a| a.name == name }
    end

    def algorithm
      @algorithm ||= Split.configuration.algorithm
    end

    def algorithm=(algorithm)
      @algorithm = algorithm.is_a?(String) ? algorithm.constantize : algorithm
    end

    def resettable=(resettable)
      @resettable = resettable.is_a?(String) ? resettable == "true" : resettable
    end

    def alternatives=(alts)
      @alternatives = alts.map do |alternative|
        if alternative.kind_of?(Split::Alternative)
          alternative
        else
          Split::Alternative.new(alternative, @name)
        end
      end
    end

    def winner
      Split.cache(:experiment_winner, name) do
        experiment_winner = redis.hget(:experiment_winner, name)
        if experiment_winner
          Split::Alternative.new(experiment_winner, name)
        else
          nil
        end
      end
    end

    def has_winner?
      return @has_winner if defined? @has_winner
      @has_winner = !winner.nil?
    end

    def winner=(winner_name)
      redis.hset(:experiment_winner, name, winner_name.to_s)
      @has_winner = true
      Split.configuration.on_experiment_winner_choose.call(self)
    end

    def participant_count
      alternatives.inject(0) { |sum, a| sum + a.participant_count }
    end

    def control
      alternatives.first
    end

    def reset_winner
      redis.hdel(:experiment_winner, name)
      @has_winner = false
      Split::Cache.clear_key(@name)
    end

    def start
      redis.hset(:experiment_start_times, @name, Time.now.to_i)
    end

    def start_time
      Split.cache(:experiment_start_times, @name) do
        t = redis.hget(:experiment_start_times, @name)
        if t
          # Check if stored time is an integer
          if t =~ /^[-+]?[0-9]+$/
            Time.at(t.to_i)
          else
            Time.parse(t)
          end
        end
      end
    end

    def next_alternative
      winner || random_alternative
    end

    def random_alternative
      if alternatives.length > 1
        algorithm.choose_alternative(self)
      else
        alternatives.first
      end
    end

    def version
      @version ||= (redis.get("#{name}:version").to_i || 0)
    end

    def increment_version
      @version = redis.incr("#{name}:version")
    end

    def key
      if version.to_i > 0
        "#{name}:#{version}"
      else
        name
      end
    end

    def goals_key
      "#{name}:goals"
    end

    def finished_key
      self.class.finished_key(key)
    end

    def metadata_key
      "#{name}:metadata"
    end

    def resettable?
      resettable
    end

    def reset
      Split.configuration.on_before_experiment_reset.call(self)
      Split::Cache.clear_key(@name)
      alternatives.each(&:reset)
      reset_winner
      Split.configuration.on_experiment_reset.call(self)
      increment_version
    end

    def delete
      Split.configuration.on_before_experiment_delete.call(self)
      if Split.configuration.start_manually
        redis.hdel(:experiment_start_times, @name)
      end
      reset_winner
      redis.srem(:experiments, name)
      remove_experiment_cohorting
      remove_experiment_configuration
      Split.configuration.on_experiment_delete.call(self)
      increment_version
    end

    def delete_metadata
      redis.del(metadata_key)
    end

    def load_from_redis
      exp_config = redis.hgetall(experiment_config_key)

      options = {
        resettable: exp_config["resettable"],
        algorithm: exp_config["algorithm"],
        alternatives: load_alternatives_from_redis,
        goals: Split::GoalsCollection.new(@name).load_from_redis,
        metadata: load_metadata_from_redis
      }

      set_alternatives_and_options(options)
    end

    def can_calculate_winning_alternatives?
      self.alternatives.all? do |alternative|
        alternative.participant_count >= 0 &&
        (alternative.participant_count >= alternative.completed_count)
      end
    end

    def calc_winning_alternatives
      return unless can_calculate_winning_alternatives?

      # Cache the winning alternatives so we recalculate them once per the specified interval.
      intervals_since_epoch =
        Time.now.utc.to_i / Split.configuration.winning_alternative_recalculation_interval

      if self.calc_time != intervals_since_epoch
        if goals.empty?
          self.estimate_winning_alternative
        else
          goals.each do |goal|
            self.estimate_winning_alternative(goal)
          end
        end

        self.calc_time = intervals_since_epoch

        self.save
      end
    end

    def estimate_winning_alternative(goal = nil)
      # initialize a hash of beta distributions based on the alternatives' conversion rates
      beta_params = calc_beta_params(goal)

      winning_alternatives = []

      Split.configuration.beta_probability_simulations.times do
        # calculate simulated conversion rates from the beta distributions
        simulated_cr_hash = calc_simulated_conversion_rates(beta_params)

        winning_alternative = find_simulated_winner(simulated_cr_hash)

        # push the winning pair to the winning_alternatives array
        winning_alternatives.push(winning_alternative)
      end

      winning_counts = count_simulated_wins(winning_alternatives)

      @alternative_probabilities = calc_alternative_probabilities(winning_counts, Split.configuration.beta_probability_simulations)

      write_to_alternatives(goal)

      self.save
    end

    def write_to_alternatives(goal = nil)
      alternatives.each do |alternative|
        alternative.set_p_winner(@alternative_probabilities[alternative], goal)
      end
    end

    def calc_alternative_probabilities(winning_counts, number_of_simulations)
      alternative_probabilities = {}
      winning_counts.each do |alternative, wins|
        alternative_probabilities[alternative] = wins / number_of_simulations.to_f
      end
      alternative_probabilities
    end

    def count_simulated_wins(winning_alternatives)
      # initialize a hash to keep track of winning alternative in simulations
      winning_counts = {}
      alternatives.each do |alternative|
        winning_counts[alternative] = 0
      end
      # count number of times each alternative won, calculate probabilities, place in hash
      winning_alternatives.each do |alternative|
        winning_counts[alternative] += 1
      end
      winning_counts
    end

    def find_simulated_winner(simulated_cr_hash)
      # figure out which alternative had the highest simulated conversion rate
      winning_pair = ["", 0.0]
      simulated_cr_hash.each do |alternative, rate|
        if rate > winning_pair[1]
          winning_pair = [alternative, rate]
        end
      end
      winner = winning_pair[0]
      winner
    end

    def calc_simulated_conversion_rates(beta_params)
      simulated_cr_hash = {}

      # create a hash which has the conversion rate pulled from each alternative's beta distribution
      beta_params.each do |alternative, params|
        alpha = params[0]
        beta = params[1]
        simulated_conversion_rate = Split::Algorithms.beta_distribution_rng(alpha, beta)
        simulated_cr_hash[alternative] = simulated_conversion_rate
      end

      simulated_cr_hash
    end

    def calc_beta_params(goal = nil)
      beta_params = {}
      alternatives.each do |alternative|
        conversions = goal.nil? ? alternative.completed_count : alternative.completed_count(goal)
        alpha = 1 + conversions
        beta = 1 + alternative.participant_count - conversions

        params = [alpha, beta]

        beta_params[alternative] = params
      end
      beta_params
    end

    def calc_time=(time)
      redis.hset(experiment_config_key, :calc_time, time)
    end

    def calc_time
      redis.hget(experiment_config_key, :calc_time).to_i
    end

    def jstring(goal = nil)
      js_id = if goal.nil?
        name
      else
        name + "-" + goal
      end
      js_id.gsub("/", "--")
    end

    def cohorting_disabled?
      @cohorting_disabled ||= begin
        value = redis.hget(experiment_config_key, :cohorting)
        value.nil? ? false : value.downcase == "true"
      end
    end

    def disable_cohorting
      @cohorting_disabled = true
      redis.hset(experiment_config_key, :cohorting, true.to_s)
    end

    def enable_cohorting
      @cohorting_disabled = false
      redis.hset(experiment_config_key, :cohorting, false.to_s)
    end

    protected
      def experiment_config_key
        "experiment_configurations/#{@name}"
      end

      def load_metadata_from_configuration
        Split.configuration.experiment_for(@name)[:metadata]
      end

      def load_metadata_from_redis
        meta = redis.get(metadata_key)
        JSON.parse(meta) unless meta.nil?
      end

      def load_alternatives_from_configuration
        alts = Split.configuration.experiment_for(@name)[:alternatives]
        raise ArgumentError, "Experiment configuration is missing :alternatives array" unless alts
        if alts.is_a?(Hash)
          alts.keys
        else
          alts.flatten
        end
      end

      def load_alternatives_from_redis
        alternatives = redis.lrange(@name, 0, -1)
        alternatives.map do |alt|
          alt = begin
                  JSON.parse(alt)
                rescue
                  alt
                end
          Split::Alternative.new(alt, @name)
        end
      end

    private
      def redis
        Split.redis
      end

      def redis_interface
        RedisInterface.new
      end

      def persist_experiment_configuration
        redis_interface.add_to_set(:experiments, name)
        redis_interface.persist_list(name, @alternatives.map { |alt| { alt.name => alt.weight }.to_json })
        goals_collection.save

        if @metadata
          redis.set(metadata_key, @metadata.to_json)
        else
          delete_metadata
        end
      end

      def remove_experiment_configuration
        @alternatives.each(&:delete)
        goals_collection.delete
        delete_metadata
        redis.del(@name)
      end

      def experiment_configuration_has_changed?
        existing_experiment = Experiment.find(@name)

        existing_experiment.alternatives.map(&:to_s) != @alternatives.map(&:to_s) ||
          existing_experiment.goals != @goals ||
          existing_experiment.metadata != @metadata
      end

      def goals_collection
        Split::GoalsCollection.new(@name, @goals)
      end

      def remove_experiment_cohorting
        @cohorting_disabled = false
        redis.hdel(experiment_config_key, :cohorting)
      end
  end
end
